fix: stop dropping the variables a style's paints and effects bind - #166
Merged
Conversation
…n the wire Figma does not keep a fill or shadow colour's variable binding in the owning node's `boundVariables` — it keeps it on the paint, the gradient stop, the effect or the layout grid itself, and a text style keeps its typography bindings on the style (its values are scalars, so there is no per-object level to hang them on). None of those had a place in the wire shapes. `SerializedBindings` is `field -> variable id`, optional everywhere it appears, so anything unbound serializes exactly as before. Field names pass through as Figma reports them rather than being filtered against a hard-coded list: a newly bindable field then rides along instead of being silently dropped. `GetStylesResult` gains the `variables` id -> token table get_design_context already returns, so a binding is readable without a second round trip. Names are not inlined into the bindings: variable names collide across collections (a local and a library `primary`), so the id stays the key.
…grid binds
serializePaint / serializeEffect / serializeLayoutGrid each picked a fixed set
of fields and never read `boundVariables`, so a value the designer bound to a
variable came back as a plain literal — indistinguishable from one that was
hard-coded. That is every SOLID paint's colour, every gradient stop's colour,
a shadow's colour / radius / spread / offsetX / offsetY, and a layout grid's
sectionSize / count / offset / gutterSize, on both style and node reads.
The fixtures are the raw shapes a live file actually returns, measured against
the plugin API rather than inferred — including the one a hand-written fixture
would never guess: an UNBOUND paint carries `boundVariables: {}`, which must
not turn into an empty field in the payload.
get_design_context is deliberately left alone: its globalVars bundles are the
budget-constrained hot path, a node's own `boundVariables` already names every
variable its fills / strokes / effects reference (measured: populated both for
a binding made on the node and for one inherited from a shared style), and all
the bundle would add is which field of which paint. Pinned by tests so the
boundary cannot flip by accident, including that a bound and an unbound copy
of the same colour still share one bundle.
…y point at
Closes the reported half of the miss: an effect style whose shadow colour is
bound to a variable came back as `color: {r,g,b,a}` with nothing saying it was
a token, so a consumer could not tell a genuinely hard-coded shadow from one
that merely serializes to its variable's current value.
Paint / effect / grid styles get theirs from the serializer now. A text style
needs its own read: its values are scalars, so `TextStyle.boundVariables` is
the only place a bound fontSize / lineHeight / letterSpacing exists.
The style-level `boundVariables` those three style kinds also expose is
deliberately not read. Measured against a live file it is a flat
`VariableAlias[]` of whatever is bound somewhere in the style's array — it
names neither which paint/effect nor which field — so it is a strictly lossy
summary of what the per-object bindings now carry exactly.
Referenced ids resolve to `{ name, type, codeSyntax? }` in a `variables` table,
mirroring get_design_context. It leads the result so a reader meets
`VariableID:5:12` already knowing what it names, is omitted when the document
binds nothing, and is assembled in walk order rather than as the parallel
lookups settle — otherwise the same document could serialize two different
byte sequences on two runs.
Missing one of these bindings is invisible: the payload still carries a perfectly good literal, it has just quietly stopped saying the value is a token. That is how the reported bug survived — and the serializer's field lists have no compile-time coupling to the typings, so nothing would catch the next one. `plugin-api.d.ts` names every bindable surface with a `VariableBindable*Field` alias, which makes that list the authoritative inventory. Recording it turns "Figma made a new kind of object bindable" into a CI failure on the typings bump, the one moment someone is looking. Fields inside an existing family need no entry — the serializer passes field names through, so a new one rides along.
…a literal get_styles now shows which of a style's values are references rather than literals, which makes the failure it enables worth naming: re-syncing a style ramp from code through update_* writes the resolved number or colour back over the binding, and the style silently stops tracking the token it was built on. Change the variable instead.
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #164.
What was wrong
Figma does not keep a paint's or effect's variable binding in the owning node's
boundVariables— it keeps it on the object itself.serializePaint,serializeEffectandserializeLayoutGrideach picked a fixed set of fields andnever read it, so a value the designer bound to a variable came back as a plain
literal, indistinguishable from a hard-coded one.
get_stylesalso never read aTextStyle's ownboundVariables, which is the only place a boundfontSize / lineHeight / letterSpacing exists.
The report names an effect colour; the same drop covered:
SolidPaintcolor— every paint style and every node's fills/strokesColorStopcolor— each gradient stop binds independentlyEffectcolor,radius,spread,offsetX,offsetYLayoutGridsectionSize,count,offset,gutterSizeTextStyleWhat changed
boundVariables({ field: variableId }),optional, emitted only when something is actually bound.
get_stylesreturns avariablestable (id → { name, type, codeSyntax? }),the same shape
get_design_contextalready returns, leading the result so areader meets an id already knowing what it names. Ids stay the key because
variable names collide across collections (a local and a library
primary).VariableBindable*Fieldfamily the typings declare, so a future Figma release that makes a new kind of
object bindable fails CI instead of silently repeating this bug.
Deliberately not done
boundVariables(PaintStyle.paints/EffectStyle.effects/GridStyle.layoutGrids) is not read. Measured against a live file it is a flatVariableAlias[]of whatever is bound somewhere in the array — it names neitherwhich paint/effect nor which field — a strictly lossy summary of what the
per-object bindings now carry exactly.
get_design_context's globalVars bundles stay unchanged: that is thebudget-constrained hot path, a node's own
boundVariablesalready names everyvariable its fills/strokes/effects reference (measured: populated both for a
binding made on the node and for one inherited from a shared style), and all the
bundle would add is which field of which paint. Pinned by tests.
SerializedPaint/SerializedEffect/SerializedLayoutGridare bidirectional, and the write tools ignore
boundVariables— documented on theschema, with a
figma-buildrule against writing a literal back over a bound value.Round-tripping a binding through
set_*/update_*is a separate change.Measured on a real design system
Run against a production file after the fix, its 23 text styles came back with
69 bindings that were invisible before — every one binds
fontSize,fontStyleandfontFamily— resolved to 15 distinct tokens (size/5xl,weight/Bold,Font family, …). Two things that file settles:(
VariableID:<key>/<id>), andgetVariableByIdAsyncnames them — soget_stylescan name tokensget_variable_defs, which returns localvariables only, cannot.
colour and blur were bound in the Figma UI and saved as an effect style now
returns
boundVariables: { color: "VariableID:…", radius: "VariableID:…" }next to the literal, with both ids named in
variables— one of them a libraryvariable carrying the designer's own
codeSyntax(var(--sds-size-icon-small)),so the consumer gets the project-side token name without a heuristic join.
boundVariables: { effects: [id1, id2] }— two ids, no indication which is thecolour and which is the radius. Only the per-effect map says that. It is the same
reason the style-level list is not read.
size/2xlappears twice(a local
VariableID:1:1253and a libraryVariableID:3ad2…/675:134), as doesweight/Regular. Inlining names into the bindings would have merged twodifferent tokens.
Verification
the test fixtures — including the detail a hand-written fixture would never guess:
an unbound paint carries
boundVariables: {}, which must not become an emptyfield in the payload.
byte-identical; bound content only ever gains a
boundVariableskey, withno existing value changed or removed.
shadow (colour + radius) both inline on a node and saved as an effect style, the
23 text styles, the
variablestable over local and library variables, andcodeSyntax. Gradient-stop and layout-grid bindings are covered by unit testsagainst probe-measured shapes rather than end to end — no tool can create either
binding, so they need a hand-made one in the Figma UI.